iT邦幫忙

2025 iThome 鐵人賽

DAY 20
1
生成式 AI

iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線系列 第 20

Day 20 - 【AI 專案 I】AI 設計師:打造智慧配色 App

  • 分享至 

  • xImage
  •  

昨天,我們成功用 App 完成了與 AI 的第一次對話。

現在,我們不只要 AI 跟我們「聊天」,更要讓 AI 成為我們的「創意工具」。今天的專案目標是當我在 App 中輸入一段文字描述,例如: 寧靜的森林早晨,我們的 AI 設計師就能自動為我生成一套代表這個情境的、專業的配色方案。

  • 困惑點:如何讓 AI 回傳我想要的「格式」?這次,我需要的不是一段描述顏色的文章,而是程式可以讀懂的顏色代碼,例如 #2F4F4F 這種十六進位碼。更重要的是,我需要的是一組「三個」互相搭配的顏色,來分別扮演「主色」、「輔助色」、「強調色」。

  • AI 協作

    • 我的 Prompt:我正在打造一個『AI 設計師』App。我需要 Gemini API 根據我給的文字描述,回傳一個剛好包含三個十六進位顏色代碼的 JSON 陣列。我該如何設計我的Prompt,才能確保 AI 總是回傳我想要的、格式固定的 JSON,而不是一段普通的文字描述?

    AI 的回答:

    打造一個「AI 設計師」App 是一個很棒的專案!為了確保 Gemini API 能夠穩定地回傳你所需要的 JSON 格式,關鍵在於在 Prompt 中明確且嚴格地定義輸出的格式和內容。

    以下是如何設計 Prompt 的建議,以及一些專業考量:


    Prompt 設計範例

    你可以使用以下這段 Prompt 作為基礎,並根據你的應用場景進行調整。這個範例包含了幾個重要的結構化指令:

    你是一個專業的色彩分析助理。
    根據以下使用者提供的文字描述,生成一個 JSON 陣列,其中包含 3 個十六進位顏色代碼。
    請嚴格遵守以下規則:
    
    1.  輸出必須是一個 JSON 陣列。
    2.  陣列中只能包含 3 個字串元素。
    3.  每個字串元素必須是一個有效的 6 位數十六進位顏色代碼(例如:"#FFFFFF")。
    4.  除了 JSON 陣列本身,不要包含任何額外的文字、解釋或註解。
    
    使用者描述:
    [在此處插入你的使用者文字描述,例如:「一個充滿活力的海灘日落,帶有橘色、粉紅色和紫色的色調。」]
    
    範例輸出:
    ["#F4A261", "#E76F51", "#2A9D8F"]
    

    設計原理與重點說明

    1. 明確的角色設定(Role-playing)
      • 在 Prompt 的開頭,將 Gemini 設定為一個具有特定職責的角色,例如「你是一個專業的色彩分析助理」。這有助於引導模型進入特定的思考模式,並專注於任務。
    2. 嚴格的格式指令(Strict Formatting Instructions)
      • 這是最重要的部分。明確列出你對輸出格式的所有要求,例如:
        • 「輸出必須是一個 JSON 陣列。」
        • 「陣列中只能包含 3 個字串元素。」
        • 「每個字串元素必須是一個有效的 6 位數十六進位顏色代碼。」
      • 使用條列式清單 (1., 2., 3.) 可以讓指令更清晰,減少模型誤解的可能性。
    3. 禁止額外內容(No Extra Text)
      • 使用「除了...不要包含任何額外的文字、解釋或註解」這類語句來明確告訴模型,你只想要 JSON 陣列,不希望它回傳任何額外的自然語言,例如「這是一組為您設計的顏色...」這類話語。這對自動化處理非常關鍵。
    4. 提供範例(Few-shot Example)
      • 在 Prompt 中提供一個「範例輸出」是一個非常有效的方法。這不僅能讓模型了解你想要的格式,還能展示一個實際的成功案例,讓它更容易模仿。即使你的指令已經很明確,範例也能顯著提升成功率。

    專業建議

    • 系統層級指令:對於更複雜或對格式要求極高的應用,除了使用者給予的 Prompt,你也可以考慮在 API 呼叫的設定中,使用 System Instructions(如果 API 支援)。這類指令通常會在每次對話開始前給予模型一個基礎的行為準則,比在每個 Prompt 中重複相同的格式要求更有效。
    • 錯誤處理:儘管透過上述設計可以大幅提升成功率,但 AI 模型的輸出仍存在不可預測性。在你的 App 中,務必實作 robust 的錯誤處理機制。例如:
      • JSON 解析檢查:在接收到 API 回應後,先檢查它是否能被正確解析為 JSON。
      • 內容格式驗證:檢查陣列中的元素數量是否為 3,以及每個字串是否符合十六進位代碼的正規表示式(Regular Expression)。
      • 如果驗證失敗,可以選擇重新發送請求,或者使用預設的顏色組合作為備援方案。

    透過這些結構化且嚴格的 Prompt 設計,可以讓你的「AI 設計師」App 穩定地從 Gemini API 獲得格式固定的 JSON 顏色數據,從而簡化你的後端處理邏輯。

    • 根據 AI 的建議,我把詳細的實作步驟整理如下:
      1. 打造 UI 介面:建立一個新的 ColorPaletteViewController,並根據我們設計的「主色+輔色+強調色」佈局來排版。
      2. 升級數據模型:在 GeminiDataModels.swift 中,定義一個新的 Codable 結構來解析 AI 回傳的顏色陣列 JSON。
      3. 建立專屬 Prompt 工具:在 Model/Utilities 中建立一個 PromptFactory.swift,專門用來生成我們精心設計的「調色盤 Prompt」。
      4. 擴充 GeminiAPIService:讓我們的服務能夠接收不同的 Prompt,變得更加靈活。
      5. 在 ViewController 中實現:將所有部分串連起來,實現從輸入文字到更新畫面顏色的完整流程。

步驟一:打造 UI 介面

我們建立一個新的 Xcode 專案,並新增一個 ColorPaletteViewController.swift 及其 .xib 檔案。UI 佈局採用我們討論好的「主色+輔色」三色方案:

  • 一個 UITextField 用於輸入情境描述。

  • 一個 UIButton 用於觸發生成。

  • 三個 UIView 和 六個 UILabel 用於顯示主色、輔助色和強調色及其色碼。

    image

最後,將 UI 元件與程式碼連結。特別的是,我將三個 UIView、三個用於顯示角色的 UILabel、以及三個用於顯示色碼的 UILabel 分別連接到三個不同的 IBOutlet Collection

步驟二:建立網路層與數據模型

我們直接將 Day 19 專案中的網路層程式碼拿到新專案中使用。包含了 APPError.swiftNetworkManager.swiftGeminiDataModels.swift。(程式碼同 Day 19 文章,此處不再贅述)

步驟三:建立專屬 Prompt 工具

為了不讓複雜的 Prompt 字串污染我們的 Service 層,我們建立一個專門的檔案來生產它。

檔案:PromptFactory.swift (新增)

import Foundation

enum PromptFactory {
    static func createColorPalettePrompt(from description: String) -> String {
        return """
        你是一個專業的色彩分析 AI 助理,專門根據文字描述生成相應的顏色配色方案。
        
        **任務目標:**
        根據使用者提供的描述,分析其中的情感、氛圍、季節、物體等元素,生成一個協調且符合描述的 3 色配色方案。
        
        **輸出規範:**
        - 格式:純 JSON 陣列,無其他文字
        - 內容:恰好 3 個十六進位顏色代碼
        - 格式:每個顏色代碼必須以 # 開頭,後接 6 位大寫十六進位數字
        - 順序:按照主色調到輔助色調排列
        
        **分析步驟:**
        1. 識別描述中的關鍵詞(情感、物體、季節、時間等)
        2. 確定主要色調和情感基調
        3. 選擇 1 個主色
        4. 搭配 1 個輔助色
        5. 添加 1 個強調色
        6. 確保色彩平衡和視覺協調性
        
        **使用者描述:**
        \(description)
        
        **輸出範例:**
        ["#2E86AB", "#A23B72", "#F18F01"]
        """
    }
}

步驟四:建立 GeminiAPIService

我們將 Day 19 建立的 GeminiAPIService 完全複製過來,不需要做任何修改,只需要傳入由 PromptFactory 生成的新 Prompt 即可。(程式碼同 Day 19 文章,此處不再贅述)

步驟五:在 ViewController 中實現

最後,我們在 ColorPaletteViewController 中,將所有部分串連起來。

import UIKit

class ColorPaletteViewController: UIViewController {
    
    // MARK: - IBOutlet
    @IBOutlet weak var txfDescription: UITextField!
    @IBOutlet weak var btnSent: UIButton!
    @IBOutlet var vColors: [UIView]!
    @IBOutlet var lbColors: [UILabel]!
    @IBOutlet var lbColorsText: [UILabel]!

    
    // MARK: - Property
    private let geminiService = GeminiAPIService()
    
    // MARK: - LifeCycle
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    // MARK: - IBAction
    @IBAction func generateButtonTapped(_ sender: UIButton) {
        // 確保輸入框有文字
        guard let description = txfDescription.text, !description.isEmpty else {
            print("錯誤:請輸入顏色描述")
            return
        }
        
        // 收起鍵盤並更新按鈕狀態,提供基本的操作回饋
        view.endEditing(true)
        btnSent.isEnabled = false
        btnSent.setTitle("生成中...", for: .normal)

        // 使用 Task 執行非同步網路請求
        Task {
            // 預設的錯誤訊息
            var errorMessage: String? = "發生未知錯誤"

            do {
                // 建立 Prompt
                let prompt = PromptFactory.createColorPalettePrompt(from: description)
                
                // 呼叫 Service
                let response = try await geminiService.generateContent(prompt: prompt)
                
                // 解析 AI 回應中的 JSON 字串
                if let colorJsonString = response.candidates.first?.content.parts.first?.text {
                    
                    // 清理字串,只保留 JSON 陣列的部分
                    let cleanJsonString = extractJsonString(from: colorJsonString)
                    
                    // 將 JSON 字串解碼成 Swift 的字串陣列
                    if let jsonData = cleanJsonString.data(using: .utf8),
                       let colorArray = try? JSONDecoder().decode([String].self, from: jsonData) {
                        
                        // 成功解析,清除錯誤訊息並準備更新 UI
                        errorMessage = nil
                        
                        await MainActor.run {
                            updateColorViews(with: colorArray)
                        }
                    } else {
                        errorMessage = "無法解析AI回傳的顏色數據"
                    }
                } else {
                    errorMessage = "AI沒有返回有效內容"
                }
            } catch {
                errorMessage = "網路或API錯誤: \(error.localizedDescription)"
            }
            
            // 無論成功或失敗,最後都回到主執行緒更新按鈕狀態
            await MainActor.run {
                btnSent.isEnabled = true
                btnSent.setTitle("生成配色", for: .normal)
                // 如果有錯誤訊息,印出來
                if let error = errorMessage {
                    print("\(error)")
                }
            }
        }
    }
    
    // MARK: - Private Methods
    /// 更新顏色 UIView 和 UILabel
    private func updateColorViews(with hexStrings: [String]) {
        let colorRoles = ["主色", "輔助色", "強調色"]
        
        for (index, hexString) in hexStrings.enumerated() {
            guard index < vColors.count && index < lbColors.count && index < lbColorsText.count else { continue }
            
            let color = UIColor(hex: hexString)
            vColors[index].backgroundColor = color
            lbColors[index].text = colorRoles[index]
            lbColorsText[index].text = hexString.uppercased()
        }
    }
    
    /// 從可能包含額外文字的字串中,提取出 JSON 陣列的部分
    private func extractJsonString(from text: String) -> String {
        // 尋找第一個 "[" 和最後一個 "]"
        if let startIndex = text.firstIndex(of: "["),
           let endIndex = text.lastIndex(of: "]") {
            // 擷取出 JSON 陣列的子字串
            return String(text[startIndex...endIndex])
        }
        // 如果找不到,直接回傳原字串嘗試解析
        return text
    }
}

// MARK: - Extensions
extension UIColor {
    convenience init(hex: String) {
        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
        var int: UInt64 = 0
        Scanner(string: hex).scanHexInt64(&int)
        let a, r, g, b: UInt64
        switch hex.count {
        case 6: // RGB (例如: "FF5733")
            (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
        case 8: // ARGB (例如: "FFFF5733")
            (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
        default:
            (a, r, g, b) = (255, 0, 0, 0) // 預設為紅色
        }
        
        self.init(
            red: CGFloat(r) / 255,
            green: CGFloat(g) / 255,
            blue: CGFloat(b) / 255,
            alpha: CGFloat(a) / 255
        )
    }
}

修改 SceneDelegate.swift

為了讓 App 啟動時顯示我們的新畫面,需要修改 SceneDelegate.swift

// 檔案:SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = (scene as? UIWindowScene) else { return }
    let rootVC = ColorPaletteViewController(nibName: "ColorPaletteViewController", bundle: nil)
    let navigationController = UINavigationController(rootViewController: rootVC)
    window = UIWindow(frame: windowScene.coordinateSpace.bounds)
    window?.windowScene = windowScene
    window?.rootViewController = navigationController
    window?.makeKeyAndVisible()
}

!!務必燒到實體機使用,不要用虛擬機測試!!因為 iOS 模擬器本身就是一台虛擬機,在虛擬機裡執行它會造成巢狀虛擬,導致網路功能完全失效而無法連線。

image image image

今日總結

今天,我們成功的指揮 AI 工作。我們也學會了提示工程 (Prompt Engineering) 這個核心技巧,透過精確的指令,讓 AI 穩定地輸出我們需要的 JSON 格式。

明天,我們將挑戰一個更複雜的專案:從零開始,打造一個能將夢境畫出來的「AI 夢境視覺化師」!


上一篇
Day 19 - 第一次接觸:使用 async/await 呼叫你的第一個生成式 AI API
下一篇
Day 21 - 【AI 專案 II】AI 夢境解析師:打造輸入介面與多頁面傳值
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
noalowo
iT邦新手 5 級 ‧ 2025-10-04 15:44:23

/images/emoticon/emoticon12.gif 太強啦

我要留言

立即登入留言